55 — 第一个 React Three Fiber 应用

Three.js Journey · Bruno Simon · 难度:简单

55 — 第一个 React Three Fiber 应用 课程海报

介绍

我们已经了解 Three.js,也了解 React(至少是基础部分)。现在是时候学习如何把 Three.js 集成到 React 中了。正如之前所见,最好的解决方案是使用 React Three Fiber(或 R3F)。

R3F 是一个 React 渲染器。我们编写 JSX,它会被渲染成 Three.js。

举个例子,创建一个由 BoxGeometry 和 MeshBasicMaterial 组成的 Mesh 就像这样简单:

<mesh>
    <boxGeometry />
    <meshBasicMaterial />
</mesh>

你可能觉得我们忘了指定诸如 BoxGeometry 尺寸之类的信息,但 R3F 会设置默认参数来简化我们的生活。而这只是 R3F 带给我们的众多便利之一。

环境搭建

首先,我们有一个非常简单的 React 项目:

/src/index.jsx 渲染的内容是 “即将成为一个超级酷炫的 R3F 应用”。

我们从 index.jsx 中加载 style.css 文件,但它目前是空的。

请注意,大多数 React 项目都会从渲染一个 App 组件开始,但其实没有这个必要,尤其是在这种学习场景下。

我们可以在终端中通过 npm run dev 来启动。

React Three Fiber 环境搭建

是时候添加 R3F 了,这其实相当容易。

首先,在终端中通过 npm install three@0.166 @react-three/fiber@8.16 添加 @react-three/fiber 和 three 依赖(我们锁定版本以避免意外,你可以忽略潜在的安全漏洞警告)。

如你所见,R3F 依赖的写法非常特殊。

开头的 @ 表示它属于一个更大的系统(我们称之为 “scope”),其名称为 react-three,而 /fiber 则是我们当前要获取的那一部分。

这意味着 @react-three scope 中还有其他部分,我们也会用到它们。

某种意义上,在谈论这个通用 scope 时,“React Three Fiber” 应该被称为 “React Three”,而这里我们只是把 Fiber 添加到项目中。

语法

正如之前提到的,R3F 是一个 React 渲染器(我们编写 JSX,它会被转换成 Three.js)。

让我们看一些示例。如果你不能理解或记住所有内容也不用担心,我们稍后会详细讲解。

一个简单的 mesh

原生 Three.js:

const mesh = new THREE.Mesh()
mesh.geometry = new THREE.BoxGeometry(1, 1, 1)
mesh.material = new THREE.MeshBasicMaterial({ color: 'red' })

scene.add(mesh)

R3F:

<mesh>
    <boxGeometry />
    <meshBasicMaterial color="red" />
</mesh>

geometry 和 material 会自动与 mesh 关联。

语法更短,也更容易理解。

默认参数会自动为我们设置好。

位置和旋转

原生 Three.js:

const mesh = new THREE.Mesh()
mesh.position.set(1, 2, 3)
mesh.rotation.x = 0.5
mesh.geometry = new THREE.BoxGeometry(1, 1, 1)
mesh.material = new THREE.MeshBasicMaterial({ color: 'red' })

scene.add(mesh)

R3F:

<mesh position={ [ 1, 2, 3 ] } rotation-x={ 0.5 }>
    <boxGeometry />
    <meshBasicMaterial color="red" />
</mesh>

同样,代码简短多了。

set 函数似乎会自动被调用,我们仍然可以单独更改像 rotation.x 这样的属性

嵌套对象

原生 Three.js:

const group = new THREE.Group()
scene.add(group)

const mesh1 = new THREE.Mesh()
mesh1.geometry = new THREE.BoxGeometry(1, 1, 1)
mesh1.material = new THREE.MeshBasicMaterial({ color: 'red' })

const mesh2 = new THREE.Mesh()
mesh2.geometry = new THREE.SphereGeometry(0.5)
mesh2.material = new THREE.MeshBasicMaterial({ color: 'orange' })

group.add(mesh1, mesh2)

R3F:

<group>
    <mesh>
        <boxGeometry />
        <meshBasicMaterial color="red" />
    </mesh>
    <mesh>
        <sphereGeometry />
        <meshBasicMaterial color="orange" />
    </mesh>
</group>

这正是基于标签的结构真正发挥作用的地方。以前,我们得费一番功夫才能弄清楚什么包含了什么。

现在,我们可以通过缩进清楚地看出来。

而这只是冰山一角。

R3F 是如何知道该如何组合组件的?

因为 JSX 是一种基于标签的语言,所以我们最终会得到嵌套的结构。

让我们再次使用之前的例子:

<group>
    <mesh>
        <boxGeometry />
        <meshBasicMaterial color="red" />
    </mesh>
    <mesh>
        <sphereGeometry />
        <meshBasicMaterial color="orange" />
    </mesh>
</group>

这里,我们很清楚地看到 <group> 里面有两个 <mesh>。

R3F 会创建 group,然后对它调用 add() 并传入两个 mesh:

const group = new THREE.Group()
scene.add(group)

const mesh1 = new THREE.Mesh()
const mesh2 = new THREE.Mesh()
group.add(mesh1, mesh2)

这是默认行为,但那 <boxGeometry>、<sphereGeometry> 和 <meshBasicMaterial> 呢?

要理解这一点,我们需要谈谈 attach 属性。

attach 属性允许开发者把组件赋予父级的某个特定属性,而不是尝试对它调用 add()。

如果我们用 attach 属性来改写之前的例子,会是这样:

<group>
    <mesh>
        <boxGeometry attach="geometry" />
        <meshBasicMaterial attach="material" color="red" />
    </mesh>
    <mesh>
        <sphereGeometry attach="geometry" />
        <meshBasicMaterial attach="material" color="orange" />
    </mesh>
</group>

<boxGeometry> 和 <sphereGeometry> 被赋予 <mesh> 的 geometry 属性。

<meshBasicMaterial> 被赋予父级 <mesh> 的 material 属性。

R3F 不会尝试对它们调用 add() 添加到 <mesh>。

但等等,之前我们并没有写那些 attach 属性,对吧?

是的,这是因为 R3F 会先检查组件的名称:

这又是众多旨在让开发者生活更轻松的特性之一。

支持哪些 Three.js 类?

你可能已经注意到,我们用 camelCase(小驼峰命名)来编写各种组件(<mesh>、<boxGeometry> 等),而这些组件对应着 Three.js 类(THREE.Mesh、THREE.BoxGeometry 等)

但具体支持哪些 Three.js 类呢?

全部支持!

R3F 会自动实现所有类。这很棒,因为当 Three.js 更新、新增或更改类时,R3F 不会损坏,那些新类无需做任何事就能得到支持。

那为什么要用 camelCase 呢?我们不能用 <Mesh> 代替 <mesh> 吗?

这是 React 的约定。原生宿主元素(渲染器中可用的元素)使用 camelCase,而自定义组件则使用 PascalCase(大驼峰命名)。

就 R3F 而言,我们可以这样理解:

第一个场景

少说多做。让我们创建第一个 R3F 场景。

Canvas

要创建 canvas,我们需要导入 Canvas 依赖。

在 /src/index.jsx 中,从 @react-three/fiber 导入 Canvas:

import { Canvas } from '@react-three/fiber'

现在我们可以把它添加到 JSX 中,替换掉那些文本:

root.render(
    <>
        <Canvas></Canvas>
    </>
)

屏幕上什么都没有,但如果你检查 DOM,就会看到 canvas 已经被创建了:

让我们在屏幕上显示点东西。

添加以下代码:

root.render(
    <>
        <Canvas>
            <mesh>
                <torusKnotGeometry />
                <meshNormalMaterial />
            </mesh>
        </Canvas>
    </>
)

你应该会看到屏幕上方有一个小球体。

调整尺寸

默认情况下,R3F 创建的 <canvas> 会采用 <Canvas> 父级的大小,也就是 #root 元素。

要解决这个问题,我们可以让 #root 填满视口,并对 <html> 和 <body> 做同样的处理。

在 /src/style.css 中添加以下代码:

html,
body,
#root
{
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
}

这算是一种有点傻的技巧,用来确保一切填满视口且没有滚动条。

<canvas> 应该填满视口。

就这样,我们已经完成了第一个 Three.js 应用!

特性

让我们稍微聊聊这里发生了什么:

这是一种简洁高效的启动项目的方式,不是吗?

重构

在继续之前,让我们稍微重构一下应用。

目前,我们有一个 Fragment(<></>),然后是 <Canvas>

它有点多余,我们可以去掉这个 Fragment:

root.render(
    <Canvas>
        <mesh>
            <torusKnotGeometry />
            <meshNormalMaterial />
        </mesh>
    </Canvas>
)

在课程的后面,我们会使用一些只能在 Canvas 内部创建的组件中生效的 hooks。

让我们创建一个 Experience 组件,把与 R3F 相关的一切都封装在里面。

创建一个 /src/Experience.jsx 文件,并把 <mesh> 放进去:

export default function Experience()
{
    return <>
        <mesh>
            <torusKnotGeometry />
            <meshNormalMaterial />
        </mesh>
    </>
}

我们使用了一个 fragment(<></>),以便向其中添加更多内容(别忘了组件只能返回一个元素)。

在 index.jsx 中导入 Experience,并把它添加到 <Canvas> 里面:

// ...
import Experience from './Experience.jsx'

// ...

root.render(
    <Canvas>
        <Experience />
    </Canvas>
)

现在我们可以在 Experience 内部使用那些特定的 hooks 了,不过那是稍后的事了。

创建和处理 mesh

我们用标签创建了第一个 mesh,而且不需要导入那些标签。

我们把这种编码方式称为 “声明式”。

R3F 会自动创建相应的实例,并把它们添加到场景中(场景也是自动创建的)。

在 Experience.jsx 中,把 geometry 和 material 替换为 <sphereGeometry> 和 <meshBasicMaterial>

<mesh>
    <sphereGeometry />
    <meshBasicMaterial />
</mesh>

而且它仍然能正常工作。

如果你查看 SphereGeometry 类的文档,前三个参数是:

R3F 提供了默认参数,但如果我们想修改它们呢?

我们可以通过给 args 属性提供一个数组来更改构造函数参数,并遵循参数顺序(radius、widthSegments、heightSegments):

<mesh>
    <sphereGeometry args={ [ 1.5, 32, 32 ] } />
    <meshBasicMaterial />
</mesh>

我们传给 args 属性的值始终与原始 Three.js 类的顺序一致。无论是几何体、材质,还是任何其他自动生成的组件都是如此。这意味着你仍然可以把 Three.js 文档作为参考。

就几何体而言,注意不要过于频繁地更新这些值或对它们做动画,因为每次更改都会导致整个几何体被重建。

对于 <meshBasicMaterial> 来说略有不同,因为 MeshBasicMaterial 只需要一个参数,而且是一个对象。

我们可以给 args 属性提供一个包含对象的数组:

<meshBasicMaterial args={ [ { ... } ] } />

但更好的修改方式是用对应的属性直接设置值。

举个例子,我们可以用同名的属性来控制 color 和 wireframe:

<mesh>
    <sphereGeometry args={ [ 1.5, 32, 32 ] } />
    <meshBasicMaterial color="mediumpurple" wireframe />
</mesh>

<meshBasicMaterial color="mediumpurple" wireframe={ false } />

让我们改用不带参数的 <boxGeometry>,并去掉 wireframe 属性上的 false:

<mesh>
    <boxGeometry />
    <meshBasicMaterial color="mediumpurple" wireframe />
</mesh>

正如我们在材质上更改了 color 或 wireframe 一样,我们也可以调整 <mesh> 上的 position、rotation 和 scale。

更改 scale:

<mesh scale={ [ 3, 2, 1 ] }>

这样做等同于 mesh.scale.set(3, 2, 1)。

如果我们想给 x、y 和 z 发送相同的值,可以只提供一个值:

<mesh scale={ 1.5 }>

然后我们可以通过更改 position 来移动它:

<mesh position={ [ 2, 0, 0 ] } scale={ 1.5 }>

这里,我们只更改了 mesh.position.x 属性,因为其他轴都是 0。

我们可以通过指定某个轴来更轻松地做到这一点:

<mesh position-x={ 2 } scale={ 1.5 }>

甚至可以发送不带 {} 的属性:

<mesh position-x="2" scale={ 1.5 }>

这样提供的值会被当作字符串。在我们的例子中问题不大,但在非常特殊的情况下可能会产生 bug,因此我们应该避免这样做。

我们也可以对 rotation 做同样的处理:

<mesh rotation-y={ Math.PI * 0.25 } position-x={ 2 } scale={ 1.5 }>

练习

让我们稍微练习一下。现在是时候自己尝试完成下面这个练习了。

在左边创建一个橙色球体,保留右边的紫色立方体(不带 wireframe),并在它们下方添加一个绿色平面,就像地板一样。

下面是解决方案:

<mesh position-x={ - 2 }>
    <sphereGeometry />
    <meshBasicMaterial color="orange" />
</mesh>

<mesh rotation-y={ Math.PI * 0.25 } position-x={ 2 } scale={ 1.5 }>
    <boxGeometry />
    <meshBasicMaterial color="mediumpurple" />
</mesh>

<mesh position-y={ - 1 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    <planeGeometry />
    <meshBasicMaterial color="greenyellow" />
</mesh>

请注意,就像原生 Three.js 一样,顺序并不重要。

动画

我们的小场景很不错,但我们希望东西能移动起来。

正如我们之前所说,场景已经在每一帧被绘制了,但因为没有东西在移动,所以我们看不出来。

我们要让立方体旋转,为此我们将使用 R3F 提供的 useFrame hook。

useFrame 只能从 <Canvas> 内部的组件中调用,这就是我们创建 Experience 组件的原因。

从 @react-three/fiber 导入 useFrame:

import { useFrame } from '@react-three/fiber'

然后在 Experience 组件内部调用它,并传入一个函数:

export default function Experience()
{
    useFrame(() =>
    {
        console.log('tick')
    })

    // ...
}
lesson demo video preview

你可能已经猜到了,无论当前帧率如何,那个函数都会在每一帧被调用。

但我们怎样才能在这里访问立方体,以更新它的 rotation 属性呢?

我们将使用一个引用

从 react 导入 useRef:

import { useRef } from 'react'

用它创建一个 cubeRef(在变量名末尾加上 Ref 是可选的,但它能让变量的用途更清晰):

export default function Experience()
{
    const cubeRef = useRef()

    // ...
}

使用 ref 属性把它与对应立方体的 <mesh> 关联起来。

<mesh ref={ cubeRef } rotation-y={ Math.PI * 0.25 } position-x={ 2 } scale={ 1.5 }>

现在我们可以在 useFrame 中通过 cubeRef.current 访问这个 Mesh 并更新它。

在 useFrame 中递增它的 rotation.y 属性:

useFrame(() =>
{
    cubeRef.current.rotation.y += 0.01
})
lesson demo video preview

但我们是不是忘了什么?

有些人可能已经注意到,你的立方体比我的旋转得更快。

我们在递增 rotation.y 时没有考虑帧率。如果设备的帧率更高,useFrame 就会被更频繁地调用,立方体也会旋转得更快。

要解决这个问题,我们需要知道自上一帧以来经过了多长时间,就像我们在之前的课程中所做的那样。

好消息:useFrame 已经把这个信息作为我们函数的参数发送给我们了。但它还会先发送给我们另一个名为 state 的信息,这就是为什么我们需要指定两个参数:

useFrame((state, delta) =>
{
    console.log(delta)
    cubeRef.current.rotation.y += 0.01
})
lesson demo video preview
useFrame((state, delta) =>
{
    cubeRef.current.rotation.y += delta
})
lesson demo video preview

现在无论设备的帧率如何,我们的旋转速度都一样了。

群组

就像在 Three.js 中一样,我们可以用 Group 类来对物体进行分组。

我们要把立方体和球体(不包括地板)分到一组,然后像旋转木马一样为该群组制作动画。

把立方体和球体包裹在一个 <group> 标签中:

<group>
    <mesh position-x={ - 2 }>
        <sphereGeometry />
        <meshBasicMaterial color="orange" />
    </mesh>

    <mesh ref={ cubeRef } rotation-y={ Math.PI * 0.25 } position-x={ 2 } scale={ 1.5 }>
        <boxGeometry />
        <meshBasicMaterial color="mediumpurple" />
    </mesh>
</group>

因为我们想为它制作动画,所以需要把它与一个引用关联起来。

创建一个 groupRef 引用:

export default function Experience()
{
    const cubeRef = useRef()
    const groupRef = useRef()

    // ...
}

使用 ref 属性把它与 <group> 关联起来:

<group ref={ groupRef }>

现在我们可以在 useFrame 中像操作 cubeRef 那样为它制作动画了:

useFrame((state, delta) =>
{
    cubeRef.current.rotation.y += delta
    groupRef.current.rotation.y += delta
})
lesson demo video preview

如你所见,绕着中心移动的并不是球体或立方体,而是这个群组。

趁我们还没晕,把群组的旋转去掉吧。

useFrame((state, delta) =>
{
    cubeRef.current.rotation.y += delta
    // groupRef.current.rotation.y += delta
})

OrbitControls

我们想添加 OrbitControls,以便围绕场景旋转相机。

你会看到,自己实现它比添加一个简单的 <mesh> 要复杂一些,但我们会在后面的课程中看到一个快得多的解决方案。

正如之前课程中讨论过的,OrbitControls 不属于 Three.js 的默认类(即 THREE 变量中可用的那些类)。

这意味着我们不能像声明 <mesh>、<meshBasicMaterial> 或 <sphereGeometry> 那样简单地声明它。

我们要导入它,并把它 “转换” 成声明式的版本。

首先,像之前的课程那样从 three/examples 导入 OrbitControls:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

然后从 @react-three/fiber 导入 extend:

import { extend, useFrame } from '@react-three/fiber'

Extend 会尝试自动把 Three.js 类转换成声明式版本,并让它在 JSX 中可用。

对 OrbitControls 类调用 extend():

import { useFrame, extend } from '@react-three/fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

extend({ OrbitControls: OrbitControls })

注意,我们需要给 extend() 提供一个对象,这样 R3F 才知道它应该叫什么名字。而由于我们提供的值与键相同,所以可以这样写:

import { useFrame, extend } from '@react-three/fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

extend({ OrbitControls })

现在我们可以在 JSX 中使用 <orbitControls>(camelCase 形式)了,但别忘了我们需要把 相机和 DOM 元素传给它。

但我们能在哪里找到它们呢?

如果你还记得前面的内容,我们在使用 useFrame 时在 state 变量中找到了它们。

但我们不想在每一帧都获取 state;我们只想要在一切准备就绪时获取一次。

我们可以用 useThree hook 来做到这一点;

从 @react-three/fiber 导入 useThree:

import { useThree, extend, useFrame } from '@react-three/fiber'

现在在组件开头调用它:

export default function Experience()
{
    const three = useThree()
    console.log(three)

    // ...
}

three 包含我们所需要的一切,比如:

让我们解构 three,只获取 camera 和 gl:

export default function Experience()
{
    const { camera, gl } = useThree()

    // ...
}

并用它们声明我们的 <orbitControls>:

export default function Experience()
{
    const { camera, gl } = useThree()

    // ...

    return <>

        <orbitControls args={ [ camera, gl.domElement ] } />

        {/* ... */}
    </>
}
lesson demo video preview

现在我们就可以像之前那样旋转了。

如你所见,这个过程有点繁琐,而且我们甚至还没有添加像阻尼(damping)这样的特性,不过别担心,我们稍后会看到一种简单得多的实现方式。

灯光

让我们的场景更真实一些,添加一些灯光。

添加一个 <directionalLight>:

export default function Experience()
{
    // ...

    return <>

        <directionalLight />

        {/* ... */}
    </>
}

没有变化?这是因为我们在物体上使用的是 <meshBasicMaterial>,而这个材质不会对光做出反应。

把所有 <meshBasicMaterial> 替换为 <meshStandardMaterial>(我们可以保留 color 属性):

<directionalLight />

<group ref={ groupRef }>
    <mesh position-x={ - 2 }>
        <sphereGeometry />
        <meshStandardMaterial color="orange" />
    </mesh>

    <mesh ref={ cubeRef } rotation-y={ Math.PI * 0.25 } position-x={ 2 } scale={ 1.5 }>
        <boxGeometry />
        <meshStandardMaterial color="mediumpurple" />
    </mesh>
</group>

<mesh position-y={ - 1 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    <planeGeometry />
    <meshStandardMaterial color="greenyellow" />
</mesh>

默认情况下,灯光来自正上方。

我们可以通过 position 属性来更改它,而它本身可以通过 position 属性来设置:

<directionalLight position={ [ 1, 2, 3 ] } />

就像我们更改位置一样,我们也可以更改其他属性,比如 color 或 intensity:

<directionalLight position={ [ 1, 2, 3 ] } intensity={ 4.5 } />

核心阴影有点太暗了。

我们可以通过添加一个 <ambientLight> 来解决:

export default function Experience()
{
    // ...

    return <>

        <directionalLight />
        <ambientLight intensity={ 1.5 } />

        {/* ... */}
    </>
}

我们可以进一步讨论其他灯光、环境贴图、阴影等,但那会是后面某节课的主题。

自定义几何体

创建自定义几何体要难一些,但一旦你理解了它的工作原理,它仍然非常方便。

这里,我们要创建一个 mesh,其几何体由随机三角形组成,就像我们在最初的某节课中所做的那样。

如果你还记得,我们需要创建一个 Float32Array,往里面放一堆值,用这个 Float32Array 创建一个 BufferAttribute,然后把它添加到 BufferGeometry 的 attributes 中。

在 R3F 中,原理相同,但如你所见,语法不同。

这也是使用 useMemo 的好机会。

独立的组件

为了稍微练习一下结构组织,我们要把自定义对象放到一个不同的组件中。

在 /src/ 文件夹中,创建一个 CustomObject.jsx 文件,并以默认导出的方式导出一个 CustomObject 函数组件:

export default function CustomObject()
{
    return null
}

在 Experience.jsx 中,导入 CustomObject 并把它添加到 JSX 中:

import CustomObject from './CustomObject.jsx'

// ...

export default function Experience()
{
    // ...

    return <>
        {/* ... */}

        <CustomObject />
    </>
}

由于我们返回的是 null,所以什么都不会显示。

把 null 替换为一个默认的 <mesh>,其中包含 <boxGeometry> 和一个红色的 <meshBasicMaterial>,这样我们就能看到一些东西了:

export default function CustomObject()
{
    return <mesh>
        <boxGeometry />
        <meshBasicMaterial color="red" />
    </mesh>
}

把场景拆分成更小的组件就这么简单。当某些东西变得复杂时,如果你想复用它,或者它只是理应被分离出来,就不要犹豫,创建一个专门的组件吧。

位置数组

现在我们可以来处理这个属性了。

在 CustomObject 中创建一个 verticesCount 变量。我们想要 10 个三角形,每个三角形需要 3 个点:

export default function CustomObject()
{
    const verticesCount = 10 * 3
    
    // ...
}

我们现在可以创建 Float32Array,它将包含所有顶点的位置,而我们需要指定数组的大小。

我们确实有 30 个位置,但请记住每个顶点需要 3 个值(x、y、z),这就是为什么我们需要把它乘以 3:

export default function CustomObject()
{
    const verticesCount = 10 * 3
    const positions = new Float32Array(verticesCount * 3)

    // ...
}

现在我们可以用随机值填充这个数组(我们并不真正关心三角形的位置):

export default function CustomObject()
{
    const verticesCount = 10 * 3
    const positions = new Float32Array(verticesCount * 3)
    
    for(let i = 0; i < verticesCount * 3; i++)
        positions[i] = (Math.random() - 0.5) * 3

    // ...
}

BufferGeometry 和 BufferAttribute

现在我们有了 positions 数组,可以把 <boxGeometry> 替换为 <bufferGeometry>:

<mesh>
    <bufferGeometry />
    <meshBasicMaterial color="red" />
</mesh>

红色立方体消失了,因为我们的几何体还没有设置任何顶点。

要添加 BufferAttribute,我们可以直接在 <bufferGeometry> 内部添加一个 <bufferAttribute>:

<bufferGeometry>
    <bufferAttribute />
</bufferGeometry>

现在我们可以添加一些属性了。

请记住 position 只是众多可能属性之一。还有 color、normal、uv、uv2,甚至还有自定义属性。

我们需要一种方法来指明我们要声明的这个属性是 position 属性。

我们可以用 attach 属性来做到这一点(先不要保存):

<bufferGeometry>
    <bufferAttribute
        attach="attributes-position"
    />
</bufferGeometry>

attributes-position 会让 BufferAttribute 把自身附加到 geometry.attribute.position。

然后我们可以给 <bufferAttribute> 指定 3 个属性:

<bufferGeometry>
    <bufferAttribute
        attach="attributes-position"
        count={ verticesCount }
        itemSize={ 3 }
        array={ positions }
    />
</bufferGeometry>

你可能需要在这里重新加载:

这样就得到了你的自定义几何体。

双面

观察这些三角形有点困难,因为默认情况下我们只能看到它们的正面。

如果你还记得 Three.js 材质的工作原理,我们需要把 side 属性设为 THREE.DoubleSide,但我们目前还无法访问 THREE 变量。

我们需要先从 three 导入 THREE:

import * as THREE from 'three'

然后,我们可以在 <meshBasicMaterial> 上添加 side 属性,值为 THREE.DoubleSide:

<meshBasicMaterial color="red" side={ THREE.DoubleSide } />
lesson demo video preview

注意,我们也可以只这样导入 DoubleSide:

import { DoubleSide } from 'three'

由你决定。

使用 useMemo 优化顶点

它能正常工作,但我们犯了一个错误。

请记住,CustomObject 函数内部的代码会在组件每次需要被绘制时被调用。

举个例子,组件中任何状态变化(当我们使用 useState 时)都会触发重新绘制,而我们不想重新计算整个几何体。我们只有 10 个三角形,但也可能是成千上万个。

在我们的例子中,没有理由重新绘制 CustomObject,但在其他场景中这可能会发生,这就是我们要学习如何优化它的原因。

而这正是我们在上一节课中见过的 useMemo 能帮上忙的地方。

提醒一下,useMemo 是一个 React hook,我们向它传入一个函数。它会调用该函数并记住这个值。如果组件被重新渲染,useMemo 会返回它从第一次函数调用中已经得到的值。它的作用有点像缓存。

我们还可以指定一些变量,如果它们发生变化,就会迫使 useMemo 忘记已保存的值并再次调用该函数。

从 react 导入 useMemo:

import { useMemo } from 'react'

调用 useMemo() 并把它赋值给一个 positions 变量(先不要保存,因为我们有两个 positions 变量):

export default function CustomObject()
{
    const verticesCount = 10 * 3
    const positions = new Float32Array(verticesCount * 3)
    
    for(let i = 0; i < verticesCount * 3; i++)
        positions[i] = (Math.random() - 0.5) * 3

    const positions = useMemo()

    // ...
}

把函数作为第一个参数传给它,第二个参数传一个空数组:

const positions = useMemo(() =>
{
}, [])

这里,唯一可能改变我们 positions 的变量是 verticesCount,但由于它是固定值,我们可以让数组保持为空。

React 会记住这个值,并且再也不会调用该函数。

最后,我们需要把 positions 数组的指令移动到那个函数里(把 verticesCount 留在函数外部,因为我们在 <bufferAttribute> 中要用到它):

export default function CustomObject()
{
    const verticesCount = 10 * 3

    const positions = useMemo(() =>
    {
        const positions = new Float32Array(verticesCount * 3)
        
        for(let i = 0; i < verticesCount * 3; i++)
            positions[i] = (Math.random() - 0.5) * 3

        return positions
    })

    // ...
}

我们得到了相同的结果,但现在我们确保了如果组件被重新渲染,positions 数组不会被重新计算。

计算顶点法线

让我们用 <meshStandardMaterial> 而不是 <meshBasicMaterial> 来测试我们的自定义对象:

<mesh>
    {/* ... */}
    <meshStandardMaterial color="red" side={ THREE.DoubleSide } />
</mesh>

如你所见,尽管场景中有灯光,我们的三角形还是有问题。

这是因为我们没有为几何体提供任何法线,三角形不知道自己朝向哪里。

与其自己计算并发送 normal 属性,我们可以让 Three.js 通过 BufferGeometry 上的 computeVertexNormals 来完成。

从 react 导入 useRef:

import { useRef, useMemo } from 'react'

创建一个 geometryRef 引用

export default function CustomObject()
{
    const geometryRef = useRef()

    // ...
}

使用 ref 属性把它与 <bufferGeometry> 关联起来:

<bufferGeometry ref={ geometryRef }>
    {/* ... */}
</bufferGeometry>

一种幼稚的做法是在 geometryRef.current 上调用 computeVertexNormals()(不要这样做):

export default function CustomObject()
{
    // ...

    const geometryRef = useRef()

    geometryRef.current.computeVertexNormals()

    // ...
}

虽然如果应用已经在运行、并且我们的 geometryRef 已经包含了该几何体,这种做法可能行得通,但在重新加载时我们仍然会遇到错误,因为在第一次绘制时 geometryRef.current 是 undefined。

要解决这个问题,我们可以使用 useEffect。

从 react 导入 useEffect:

import { useEffect, useRef, useMemo } from 'react'

在 useMemo 之后调用它:

export default function CustomObject()
{
    // ...

    const positions = useMemo(() =>
    {
        // ...
    })

    useEffect(() =>
    {
        
    }, [])

    // ...
}

虽然它不应该变化,我们仍然可以把 positions 放进依赖数组中:

useEffect(() =>
{
    
}, [ positions ])

在那个函数中,我们可以在 geometryRef.current 上调用 computeVertexNormals():

useEffect(() =>
{
    geometryRef.current.computeVertexNormals()
}, [ positions ])
lesson demo video preview

而且我们不需要测试 current 是否为 undefined,因为 useEffect 会在第一次绘制之后被调用。

自定义几何体到此结束,你可能觉得最终的代码有点复杂和绕,但这主要是因为它很新。

Canvas 设置

正如我们之前所说,<Canvas> 组件为我们设置了一大堆东西(场景、相机、渲染器、antialias、colorSpace 等),以便用最少的精力就能让它看起来不错。

但有时,我们需要更改这些设置。

别担心,这些设置仍然可以访问,而且其中大部分可以直接通过 <Canvas> 上的属性来更改。

相机

让我们从相机开始。

设置和位置

要更改相机,在 index.jsx 中给 <Canvas> 添加一个 camera 属性,并传入一个对象:

<Canvas camera={ {} }>
    <Experience />
</Canvas>

在这个对象里,我们可以按照相机的默认结构来传入参数,比如 fov、near 或 far:

<Canvas camera={ { fov: 45, near: 0.1, far: 200 } }>

但现在体验开始时相机离得太近了。

我们可以用常见的 position 属性来移动它:

<Canvas camera={ { fov: 45, near: 0.1, far: 200, position: [ 3, 2, 6 ] } }>

我们开始有大量的属性了,而且越来越难阅读。

我们可以通过添加一些换行来改善:

root.render(
    <Canvas
        camera={ {
            fov: 45,
            near: 0.1,
            far: 200,
            position: [ 3, 2, 6 ]
        } }
    >
        <Experience />
    </Canvas>
)

再说一次,这取决于你的个人偏好,因为我知道很多人不会喜欢我写代码的方式。你可以按自己喜欢的方式写代码,但我建议在整个项目中保持一致。

改用 OrthographicCamera

但如果我们想要一个 OrthographicCamera 呢?

给 <Canvas> 添加一个 orthographic 属性:

<Canvas
    orthographic
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>
    <Experience />
</Canvas>
lesson demo video preview

这样做会让 fov 失效,而且视图看起来离场景非常远。

调整 top、right、bottom 和 left 属性是个坏主意,因为 R3F 已经在更新这些属性,以保持适配视口的比例。

相反,我们可以使用 zoom 属性:

<Canvas
    orthographic
    camera={ {
        fov: 45,
        zoom: 100,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>
    <Experience />
</Canvas>

我们也可以在做 render() 之前创建一个包含这些设置的变量:

const cameraSettings = {
    fov: 45,
    zoom: 100,
    near: 0.1,
    far: 200,
    position: [ 3, 2, 6 ]
}

root.render(
    <Canvas
        orthographic
        camera={ cameraSettings }
    >
        <Experience />
    </Canvas>
)

但还是让我们沿用属性方式,并回到不带 zoom 的默认 PerspectiveCamera:

<Canvas
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

为相机制作动画

目前,我们使用的是 <orbitControls>,但如果我们想为相机制作动画,而不是让用户控制它呢?

把 <orbitControls> 注释掉:

{/* <orbitControls args={ [ camera, gl.domElement ] } /> */}

之前我们了解到,传给 useFrame 的函数第一个参数是我们 Three.js 体验的 state。

这个 state 包含了我们的相机:

useFrame((state, delta) =>
{
    console.log(state.camera)

    cubeRef.current.rotation.y += delta
    // groupRef.current.rotation.y += delta
})
lesson demo video preview

我们要让相机围绕场景旋转,同时始终聚焦于中心。

这意味着相机沿着一条圆形轨迹移动。首先,我们需要一个角度,然后在一个 sin() 和 cos() 上使用这个角度,以得到 x 和 z 坐标。

要得到这个角度,我们可以使用自体验开始以来经过的时间。R3F 实现了一个 Three.js Clock,它会包含经过的时间,我们可以在 state 中通过 state.clock 访问这个 Clock:

useFrame((state, delta) =>
{
    console.log(state.clock)

    // ...
})

我们要做的就是把它的 elapsedTime 属性保存到一个 angle 变量中:

useFrame((state, delta) =>
{
    const angle = state.clock.elapsedTime
    console.log(angle)

    // ...
})
lesson demo video preview

(注意还有一个 getElapsedTime 方法)。

现在我们可以用同一个 angle,通过 sin() 和 cos() 来更改 camera.position.x 和 camera.position.z:

useFrame((state, delta) =>
{
    const angle = state.clock.elapsedTime
    state.camera.position.x = Math.sin(angle)
    state.camera.position.z = Math.cos(angle)

    // ...
})
lesson demo video preview

让我们把 sin() 和 cos() 乘以一个系数,把圆的半径稍微放大一点:

useFrame((state, delta) =>
{
    const angle = state.clock.elapsedTime
    state.camera.position.x = Math.sin(angle) * 8
    state.camera.position.z = Math.cos(angle) * 8

    // ...
})
lesson demo video preview

现在,我们可以让相机 lookAt() 中心:

useFrame((state, delta) =>
{
    const angle = state.clock.elapsedTime
    state.camera.position.x = Math.sin(angle) * 8
    state.camera.position.z = Math.cos(angle) * 8
    state.camera.lookAt(0, 0, 0)

    // ...
})
lesson demo video preview

这只是为了学习而做的一个快速练习。让我们通过在 useFrame 中注释掉我们所写的内容,并取消注释 <orbitControls>,回到轨道控制:

useFrame((state, delta) =>
{
    // const angle = state.clock.elapsedTime
    // state.camera.position.x = Math.sin(angle) * 8
    // state.camera.position.z = Math.cos(angle) * 8
    // state.camera.lookAt(0, 0, 0)

    cubeRef.current.rotation.y += delta
    // groupRef.current.rotation.y += delta
})

return <>

    <orbitControls args={ [ camera, gl.domElement ] } />

    {/* ... */}

</>
lesson demo video preview

Antialias

抗锯齿(antialias)默认是开启的,这样看起来更好。

但如果我们想移除它,可以给 <Canvas> 添加一个 gl 属性并传入一个对象,就像我们处理 camera 那样:

<Canvas
    gl={ {} }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

gl 指的是 WebGLRenderer,我们可以通过把该属性设为 false 来停用 antialias:

<Canvas
    gl={ {
        antialias: false
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

但它看起来很差,所以让我们把它改回 true 或移除它:

<Canvas
    gl={ {
        antialias: true
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

色调映射

默认情况下,R3F 把 toneMapping 设置为 ACESFilmicToneMapping。

提醒一下,toneMapping 模仿 HDR(高动态范围)到 LDR(低动态范围)的效果。

这并非真正的 HDR 到 LDR,因为默认渲染本身就已经是 LDR。不过,它会调整颜色,使其看起来像 HDR。

总之,要移除它,我们可以给 <Canvas> 添加 flat 属性:

<Canvas
    flat
    gl={ {
        antialias: true
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

这会导致把 NoToneMapping 赋给 toneMapping(这与 NoToneMapping 相同)。

如果我们想发送特定的色调映射,比如 CineonToneMapping,可以把它添加到 gl 属性中。但首先,我们需要从 Three.js 导入它。

要导入 Three.js,我们可以用常规做法:

import * as THREE from 'three'

或者导入我们需要的特定类和属性:

import { CineonToneMapping } from 'three'

我个人倾向于导入整个 THREE 对象,这样我们就能明白所用的属性来自哪里,不过你可以选择自己喜欢的方式。

现在我们可以移除 flat 属性,并在 gl 属性中设置 toneMapping:

<Canvas
    gl={ {
        antialias: true,
        toneMapping: THREE.CineonToneMapping
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

由你决定,但我个人更喜欢 ACESFilmicToneMapping,所以我们要把 THREE.ACESFilmicToneMapping 放回去:

<Canvas
    gl={ {
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

输出色彩空间

outputColorSpace 已经被设置为 SRGBColorSpace。

正如之前课程中讨论过的,色彩空间是一种对颜色进行编码和解码的方式,这样我们就能以更优化的方式存储颜色信息,因为每个通道可能的值数量是有限的。

我们通常希望把输出色彩空间设为 SRGBColorSpace,但如果需要,也可以把它改为 LinearSRGBColorSpace:

<Canvas
    gl={ {
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping,
        outputColorSpace: THREE.LinearSRGBColorSpace
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

我们可以通过给 <Canvas> 添加 linear 属性来得到相同的结果:

<Canvas
    linear
    gl={ {
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping,
        // outputColorSpace: THREE.LinearSRGBColorSpace
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

但我们现在不需要它,所以让我们回到默认的 SRGBColorSpace:

<Canvas
    gl={ {
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping,
        outputColorSpace: THREE.LinearSRGBColorSpace
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

透明度(Alpha)

默认情况下,渲染的背景是透明的。

这意味着我们可以在它后面放置一些东西,比如 DOM 元素或 CSS 的 background-color:

html,
body,
#root
{
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: LightSkyBlue;
}

在后面的课程中,我们会讲到一种想要更改透明行为的情况。

像素比

如果你在高像素比的屏幕上测试,或者有幸拥有多台不同像素比的屏幕,你可能会注意到渲染器会自动处理它。

不过,在之前的课程中我们发现,把它限制住是一种好习惯,可以避免在像素比非常高的设备上出现性能问题。

我们可以通过给 <Canvas> 上的 dpr 属性发送一个特定值来强制设置它:

<Canvas
    dpr={ 1 }
    gl={ {
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping,
        outputColorSpace: THREE.LinearSRGBColorSpace
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

或者我们可以发送一个包含最小值和最大值的数组:

<Canvas
    dpr={ [ 1, 2 ] }
    gl={ {
        antialias: true,
        toneMapping: THREE.ACESFilmicToneMapping,
        outputColorSpace: THREE.LinearSRGBColorSpace
    } }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 3, 2, 6 ]
    } }
>

但 [ 1, 2 ] 实际上就是默认值,这意味着我们可以把它移除。

性能

我们学到的关于 Three.js 性能的一切在这里仍然适用(尽量减少 draw calls、简化模型、避免过大的贴图等)

但 R3F 和 React 也带来了一些有趣的特性来提升性能。

这些改进在 R3F 文档的以下页面中有很好的说明:Scaling performance

如果你以后需要,可以看一下,但我们稍后会把其中一些付诸实践。

延伸阅读

在接下来的课程中,我们会更深入地探讨 R3F,但如果你想自己探索,这里有一些资源。